Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit d23b169b412be2edf42f9089aaa63ff3bd40ec3f


Parents : 37585c8
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-10T11:39:19-05:00

feat(android): integrate BLE and USB support with Chaquopy, improving RNode functionality

Changes
Diff

diff --git a/android/app/build.gradle b/android/app/build.gradle
index 4f64c585..815c7b87 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -257,9 +257,9 @@ chaquopy {
throw new org.gradle.api.GradleException("Missing patched RNS wheel at ${rnsPatchedWheel}")
}
// Patched so RNS's Android RNodeInterface never calls RNS.panic() (os._exit)
- // when usbserial4a/jnius are missing. RNode over TCP needs neither and always
- // works; serial/BLE/classic-Bluetooth now fail gracefully instead of crashing
- // the app. See scripts/build-android-wheels-local.sh.
+ // when usbserial4a/jnius are missing. MeshChatX also ships a Chaquopy jnius
+ // shim, usb4a context bridge, and able BLE stack so USB/BT/BLE RNode can
+ // run. RNode over TCP needs neither native module and always works.
install rnsPatchedWheel.absolutePath
install "lxmf>=1.0.1"
install "numpy==1.26.2"

diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 481ddead..82e87548 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -82,6 +82,12 @@
android:host="app"
android:scheme="meshchatx" />
</intent-filter>
+ <intent-filter>
+ <action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
+ </intent-filter>
+ <meta-data
+ android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
+ android:resource="@xml/device_filter" />
</activity>
<service

diff --git a/android/app/src/main/java/com/meshchatx/MainActivity.java b/android/app/src/main/java/com/meshchatx/MainActivity.java
index 479e1f91..17dd2529 100644
--- a/android/app/src/main/java/com/meshchatx/MainActivity.java
+++ b/android/app/src/main/java/com/meshchatx/MainActivity.java
@@ -203,6 +203,11 @@ public class MainActivity extends AppCompatActivity {
if (!Python.isStarted()) {
Python.start(new AndroidPlatform(this));
}
+ try {
+ org.able.BLE.setAppContext(this);
+ } catch (Exception ignored) {
+ // BLE class may be unavailable in incomplete builds.
+ }
requestRuntimePermissionsIfNeeded();
WebSettings webSettings = webView.getSettings();
@@ -691,7 +696,9 @@ public class MainActivity extends AppCompatActivity {
try {
Python py = Python.getInstance();
String appFilesDir = AndroidStorageManager.resolveActiveBaseDir(MainActivity.this).getAbsolutePath();
- py.getModule("meshchat_wrapper").callAttr("start_server", SERVER_PORT, appFilesDir);
+ // Pass Activity so usb4a / org.able.BLE can open RNode USB and BLE.
+ py.getModule("meshchat_wrapper").callAttr(
+ "start_server", SERVER_PORT, appFilesDir, MainActivity.this);
} catch (Exception e) {
final String stack = toStackTrace(e);
runOnUiThread(() -> {
@@ -1309,18 +1316,52 @@ public class MainActivity extends AppCompatActivity {
@JavascriptInterface
public boolean hasUsbPermissions() {
- // WebUSB / Web Serial polyfill drives the device picker. from the
- // Android manifest standpoint USB host access is granted as soon as
- // the user accepts the per-device dialog. Surface true when we
- // have a UsbManager so the JS layer can short-circuit prompts.
UsbManager manager = (UsbManager) activity.getSystemService(Context.USB_SERVICE);
- return manager != null;
+ if (manager == null) {
+ return false;
+ }
+ java.util.HashMap<String, android.hardware.usb.UsbDevice> devices =
+ manager.getDeviceList();
+ if (devices == null || devices.isEmpty()) {
+ return true;
+ }
+ for (android.hardware.usb.UsbDevice device : devices.values()) {
+ if (!manager.hasPermission(device)) {
+ return false;
+ }
+ }
+ return true;
}
@JavascriptInterface
public void requestUsbPermissions() {
- // No-op on android: per-device prompts are issued by WebUSB itself.
- // Method is exposed so the JS bridge contract is symmetric.
+ activity.runOnUiThread(() -> {
+ try {
+ UsbManager manager =
+ (UsbManager) activity.getSystemService(Context.USB_SERVICE);
+ if (manager == null) {
+ return;
+ }
+ Intent intent = new Intent("com.meshchatx.USB_PERMISSION");
+ android.app.PendingIntent permissionIntent =
+ android.app.PendingIntent.getBroadcast(
+ activity,
+ 0,
+ intent,
+ android.app.PendingIntent.FLAG_IMMUTABLE);
+ for (android.hardware.usb.UsbDevice device :
+ manager.getDeviceList().values()) {
+ if (!manager.hasPermission(device)) {
+ manager.requestPermission(device, permissionIntent);
+ }
+ }
+ } catch (Exception e) {
+ Toast.makeText(
+ activity,
+ "USB permission request failed",
+ Toast.LENGTH_SHORT).show();
+ }
+ });
}
@JavascriptInterface

diff --git a/android/app/src/main/java/org/able/BLE.java b/android/app/src/main/java/org/able/BLE.java
new file mode 100644
index 00000000..9dabcfb4
--- /dev/null
+++ b/android/app/src/main/java/org/able/BLE.java
@@ -0,0 +1,292 @@
+package org.able;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothGatt;
+import android.bluetooth.BluetoothGattCallback;
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.bluetooth.BluetoothGattDescriptor;
+import android.bluetooth.BluetoothGattService;
+import android.bluetooth.BluetoothManager;
+import android.bluetooth.BluetoothProfile;
+import android.bluetooth.le.BluetoothLeScanner;
+import android.bluetooth.le.ScanCallback;
+import android.bluetooth.le.ScanFilter;
+import android.bluetooth.le.ScanResult;
+import android.bluetooth.le.ScanSettings;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.pm.PackageManager;
+import android.util.Log;
+
+import java.util.List;
+
+/**
+ * Android BLE helper used by MeshChatX's Chaquopy able package.
+ * Context is injected from MainActivity instead of Kivy PythonActivity.
+ */
+public class BLE {
+ private static final String TAG = "BLE-meshchatx";
+
+ public static volatile Context appContext;
+
+ private final PythonBluetooth mPython;
+ private final Context mContext;
+ private BluetoothAdapter mBluetoothAdapter;
+ private BluetoothLeScanner mBluetoothLeScanner;
+ private BluetoothGatt mBluetoothGatt;
+ private List<BluetoothGattService> mBluetoothGattServices;
+ private boolean mScanning;
+
+ public static void setAppContext(Context context) {
+ if (context != null) {
+ appContext = context.getApplicationContext();
+ }
+ }
+
+ public void showError(final String msg) {
+ Log.e(TAG, msg);
+ mPython.on_error(msg);
+ }
+
+ public BLE(PythonBluetooth python) {
+ mPython = python;
+ if (appContext == null) {
+ throw new IllegalStateException(
+ "org.able.BLE.appContext is not set. Call BLE.setAppContext from MainActivity."
+ );
+ }
+ mContext = appContext;
+ mBluetoothGatt = null;
+
+ if (!mContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE)) {
+ showError("Device does not support Bluetooth Low Energy.");
+ return;
+ }
+
+ final BluetoothManager bluetoothManager =
+ (BluetoothManager) mContext.getSystemService(Context.BLUETOOTH_SERVICE);
+ if (bluetoothManager != null) {
+ mBluetoothAdapter = bluetoothManager.getAdapter();
+ }
+ mContext.registerReceiver(
+ mReceiver,
+ new IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED)
+ );
+ }
+
+ public BluetoothAdapter getAdapter(int enableBtCode) {
+ if (mBluetoothAdapter == null) {
+ showError("Device does not support Bluetooth Low Energy.");
+ return null;
+ }
+ if (!mBluetoothAdapter.isEnabled()) {
+ showError("BLE adapter is not enabled");
+ return null;
+ }
+ return mBluetoothAdapter;
+ }
+
+ public BluetoothAdapter getMBluetoothAdapter() {
+ return mBluetoothAdapter;
+ }
+
+ public BluetoothGatt getGatt() {
+ return mBluetoothGatt;
+ }
+
+ public void startScan(int enableBtCode, List<ScanFilter> filters, ScanSettings settings) {
+ Log.d(TAG, "startScan");
+ BluetoothAdapter adapter = getAdapter(enableBtCode);
+ if (adapter == null) {
+ return;
+ }
+ if (mBluetoothLeScanner == null) {
+ mBluetoothLeScanner = adapter.getBluetoothLeScanner();
+ }
+ if (mBluetoothLeScanner != null) {
+ mScanning = false;
+ mBluetoothLeScanner.startScan(filters, settings, mScanCallback);
+ } else {
+ showError("Could not get BLE Scanner object.");
+ mPython.on_scan_started(false);
+ }
+ }
+
+ public void stopScan() {
+ if (mBluetoothLeScanner != null) {
+ Log.d(TAG, "stopScan");
+ mBluetoothLeScanner.stopScan(mScanCallback);
+ if (mScanning) {
+ mScanning = false;
+ mPython.on_scan_completed();
+ }
+ }
+ }
+
+ private final ScanCallback mScanCallback = new ScanCallback() {
+ @Override
+ public void onScanResult(final int callbackType, final ScanResult result) {
+ if (!mScanning) {
+ mScanning = true;
+ Log.d(TAG, "BLE scan started successfully");
+ mPython.on_scan_started(true);
+ }
+ mPython.on_scan_result(result);
+ }
+
+ @Override
+ public void onBatchScanResults(List<ScanResult> results) {
+ Log.d(TAG, "onBatchScanResults");
+ }
+
+ @Override
+ public void onScanFailed(int errorCode) {
+ Log.e(TAG, "BLE Scan failed, error code:" + errorCode);
+ mPython.on_scan_started(false);
+ }
+ };
+
+ public void connectGatt(BluetoothDevice device) {
+ connectGatt(device, false);
+ }
+
+ public void connectGatt(BluetoothDevice device, boolean autoConnect) {
+ Log.d(TAG, "connectGatt");
+ if (mBluetoothGatt == null) {
+ mBluetoothGatt = device.connectGatt(
+ mContext,
+ autoConnect,
+ mGattCallback,
+ BluetoothDevice.TRANSPORT_LE
+ );
+ } else {
+ Log.d(TAG, "BluetoothGatt exists, call closeGatt() before reconnecting");
+ }
+ }
+
+ public void closeGatt() {
+ Log.d(TAG, "closeGatt");
+ if (mBluetoothGatt != null) {
+ mBluetoothGatt.close();
+ mBluetoothGatt = null;
+ }
+ }
+
+ private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String action = intent.getAction();
+ if (BluetoothAdapter.ACTION_STATE_CHANGED.equals(action)) {
+ int state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, -1);
+ mPython.on_bluetooth_adapter_state_change(state);
+ }
+ }
+ };
+
+ private final BluetoothGattCallback mGattCallback = new BluetoothGattCallback() {
+ @Override
+ public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) {
+ if (newState == BluetoothProfile.STATE_CONNECTED) {
+ Log.d(TAG, "Connected to GATT server, status:" + status);
+ } else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
+ Log.d(TAG, "Disconnected from GATT server, status:" + status);
+ }
+ if (mBluetoothGatt == null) {
+ mBluetoothGatt = gatt;
+ }
+ mPython.on_connection_state_change(status, newState);
+ }
+
+ @Override
+ public void onServicesDiscovered(BluetoothGatt gatt, int status) {
+ if (status == BluetoothGatt.GATT_SUCCESS) {
+ Log.d(TAG, "onServicesDiscovered - success");
+ mBluetoothGattServices = mBluetoothGatt.getServices();
+ } else {
+ showError("onServicesDiscovered status:" + status);
+ mBluetoothGattServices = null;
+ }
+ mPython.on_services(status, mBluetoothGattServices);
+ }
+
+ @Override
+ public void onCharacteristicChanged(
+ BluetoothGatt gatt,
+ BluetoothGattCharacteristic characteristic
+ ) {
+ mPython.on_characteristic_changed(characteristic);
+ }
+
+ @Override
+ public void onCharacteristicRead(
+ BluetoothGatt gatt,
+ BluetoothGattCharacteristic characteristic,
+ int status
+ ) {
+ mPython.on_characteristic_read(characteristic, status);
+ }
+
+ @Override
+ public void onCharacteristicWrite(
+ BluetoothGatt gatt,
+ BluetoothGattCharacteristic characteristic,
+ int status
+ ) {
+ mPython.on_characteristic_write(characteristic, status);
+ }
+
+ @Override
+ public void onDescriptorRead(
+ BluetoothGatt gatt,
+ BluetoothGattDescriptor descriptor,
+ int status
+ ) {
+ mPython.on_descriptor_read(descriptor, status);
+ }
+
+ @Override
+ public void onDescriptorWrite(
+ BluetoothGatt gatt,
+ BluetoothGattDescriptor descriptor,
+ int status
+ ) {
+ mPython.on_descriptor_write(descriptor, status);
+ }
+
+ @Override
+ public void onReadRemoteRssi(BluetoothGatt gatt, int rssi, int status) {
+ mPython.on_rssi_updated(rssi, status);
+ }
+
+ @Override
+ public void onMtuChanged(BluetoothGatt gatt, int mtu, int status) {
+ Log.d(TAG, String.format("onMtuChanged mtu=%d status=%d", mtu, status));
+ mPython.on_mtu_changed(mtu, status);
+ }
+ };
+
+ public boolean writeCharacteristic(
+ BluetoothGattCharacteristic characteristic,
+ byte[] data,
+ int writeType
+ ) {
+ if (characteristic.setValue(data)) {
+ if (writeType != 0) {
+ characteristic.setWriteType(writeType);
+ }
+ return mBluetoothGatt != null && mBluetoothGatt.writeCharacteristic(characteristic);
+ }
+ return false;
+ }
+
+ public boolean readCharacteristic(BluetoothGattCharacteristic characteristic) {
+ return mBluetoothGatt != null && mBluetoothGatt.readCharacteristic(characteristic);
+ }
+
+ public boolean readRemoteRssi() {
+ return mBluetoothGatt != null && mBluetoothGatt.readRemoteRssi();
+ }
+}

diff --git a/android/app/src/main/java/org/able/PythonBluetooth.java b/android/app/src/main/java/org/able/PythonBluetooth.java
new file mode 100644
index 00000000..15db18cb
--- /dev/null
+++ b/android/app/src/main/java/org/able/PythonBluetooth.java
@@ -0,0 +1,42 @@
+package org.able;
+
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.bluetooth.BluetoothGattDescriptor;
+import android.bluetooth.BluetoothGattService;
+import android.bluetooth.le.ScanResult;
+
+import java.util.List;
+
+/**
+ * Python callback surface for {@link BLE}. Implemented from Chaquopy via
+ * {@code java.dynamic_proxy}.
+ */
+public interface PythonBluetooth {
+ void on_error(String msg);
+
+ void on_scan_started(boolean success);
+
+ void on_scan_result(ScanResult result);
+
+ void on_scan_completed();
+
+ void on_services(int status, List<BluetoothGattService> services);
+
+ void on_characteristic_changed(BluetoothGattCharacteristic characteristic);
+
+ void on_characteristic_read(BluetoothGattCharacteristic characteristic, int status);
+
+ void on_characteristic_write(BluetoothGattCharacteristic characteristic, int status);
+
+ void on_descriptor_read(BluetoothGattDescriptor descriptor, int status);
+
+ void on_descriptor_write(BluetoothGattDescriptor descriptor, int status);
+
+ void on_connection_state_change(int status, int state);
+
+ void on_bluetooth_adapter_state_change(int state);
+
+ void on_rssi_updated(int rssi, int status);
+
+ void on_mtu_changed(int mtu, int status);
+}

diff --git a/android/app/src/main/python/able/__init__.py b/android/app/src/main/python/able/__init__.py
new file mode 100644
index 00000000..13c37139
--- /dev/null
+++ b/android/app/src/main/python/able/__init__.py
@@ -0,0 +1,32 @@
+# SPDX-License-Identifier: MIT
+"""Minimal Android BLE stack for RNS RNodeInterface on Chaquopy.
+
+API-compatible with the subset of ``able`` that Reticulum's Android
+RNodeInterface imports. Uses org.able.BLE (Java) plus Chaquopy proxies
+instead of Kivy / pyjnius.
+"""
+
+from __future__ import annotations
+
+from able.structures import Advertisement, Services
+
+GATT_SUCCESS = 0
+STATE_CONNECTED = 2
+STATE_DISCONNECTED = 0
+
+__all__ = [
+ "Advertisement",
+ "BluetoothDispatcher",
+ "GATT_SUCCESS",
+ "Services",
+ "STATE_CONNECTED",
+ "STATE_DISCONNECTED",
+]
+
+
+def __getattr__(name: str):
+ if name == "BluetoothDispatcher":
+ from able.dispatcher import BluetoothDispatcher
+
+ return BluetoothDispatcher
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

diff --git a/android/app/src/main/python/able/dispatcher.py b/android/app/src/main/python/able/dispatcher.py
new file mode 100644
index 00000000..23e6b76e
--- /dev/null
+++ b/android/app/src/main/python/able/dispatcher.py
@@ -0,0 +1,287 @@
+# SPDX-License-Identifier: MIT
+"""Chaquopy BluetoothDispatcher for RNS RNode BLE."""
+
+from __future__ import annotations
+
+from able.queue import BLEQueue, ble_task, ble_task_done
+from able.structures import Services
+
+GATT_SUCCESS = 0
+
+_JAVA = None
+
+
+def _java():
+ global _JAVA
+ if _JAVA is not None:
+ return _JAVA
+ from jnius import autoclass
+
+ bluetooth_adapter = autoclass("android.bluetooth.BluetoothAdapter")
+ bluetooth_device = autoclass("android.bluetooth.BluetoothDevice")
+ bluetooth_gatt_descriptor = autoclass("android.bluetooth.BluetoothGattDescriptor")
+ ble = autoclass("org.able.BLE")
+ _JAVA = {
+ "BluetoothAdapter": bluetooth_adapter,
+ "BluetoothDevice": bluetooth_device,
+ "BluetoothGattDescriptor": bluetooth_gatt_descriptor,
+ "BLE": ble,
+ "ENABLE_NOTIFICATION_VALUE": bluetooth_gatt_descriptor.ENABLE_NOTIFICATION_VALUE,
+ "ENABLE_INDICATION_VALUE": bluetooth_gatt_descriptor.ENABLE_INDICATION_VALUE,
+ "DISABLE_NOTIFICATION_VALUE": bluetooth_gatt_descriptor.DISABLE_NOTIFICATION_VALUE,
+ }
+ return _JAVA
+
+
+def _to_java_bytes(value):
+ if value is None:
+ return []
+ if isinstance(value, (bytes, bytearray)):
+ return list(value)
+ if isinstance(value, (list, tuple)):
+ return list(value)
+ try:
+ return list(value.encode())
+ except AttributeError:
+ pass
+ try:
+ return list(value)
+ except TypeError:
+ return [value]
+
+
+def _make_python_bluetooth_proxy(dispatcher):
+ from java import dynamic_proxy, jclass
+
+ interface = jclass("org.able.PythonBluetooth")
+
+ class PythonBluetoothProxy(dynamic_proxy(interface)):
+ def __init__(self, owner):
+ super().__init__()
+ self.owner = owner
+
+ def on_error(self, msg):
+ self.owner.dispatch("on_error", msg)
+
+ def on_scan_started(self, success):
+ self.owner.dispatch("on_scan_started", success)
+
+ def on_scan_result(self, result):
+ pass
+
+ def on_scan_completed(self):
+ self.owner.dispatch("on_scan_completed")
+
+ def on_services(self, status, services):
+ services_dict = Services()
+ if status == GATT_SUCCESS and services is not None:
+ try:
+ service_list = list(services.toArray())
+ except Exception:
+ try:
+ service_list = list(services)
+ except Exception:
+ service_list = []
+ for service in service_list:
+ service_uuid = str(service.getUuid().toString())
+ services_dict[service_uuid] = {}
+ try:
+ chars = list(service.getCharacteristics().toArray())
+ except Exception:
+ chars = list(service.getCharacteristics())
+ for characteristic in chars:
+ char_uuid = str(characteristic.getUuid().toString())
+ services_dict[service_uuid][char_uuid] = characteristic
+ self.owner.dispatch("on_services", status, services_dict)
+
+ def on_characteristic_changed(self, characteristic):
+ self.owner.dispatch("on_characteristic_changed", characteristic)
+
+ def on_characteristic_read(self, characteristic, status):
+ self.owner.dispatch("on_gatt_release")
+ self.owner.dispatch("on_characteristic_read", characteristic, status)
+
+ def on_characteristic_write(self, characteristic, status):
+ self.owner.dispatch("on_gatt_release")
+ self.owner.dispatch("on_characteristic_write", characteristic, status)
+
+ def on_descriptor_read(self, descriptor, status):
+ self.owner.dispatch("on_gatt_release")
+ self.owner.dispatch("on_descriptor_read", descriptor, status)
+
+ def on_descriptor_write(self, descriptor, status):
+ self.owner.dispatch("on_gatt_release")
+ self.owner.dispatch("on_descriptor_write", descriptor, status)
+
+ def on_connection_state_change(self, status, state):
+ self.owner.dispatch("on_connection_state_change", status, state)
+
+ def on_bluetooth_adapter_state_change(self, state):
+ self.owner.dispatch("on_bluetooth_adapter_state_change", state)
+
+ def on_rssi_updated(self, rssi, status):
+ self.owner.dispatch("on_gatt_release")
+ self.owner.dispatch("on_rssi_updated", rssi, status)
+
+ def on_mtu_changed(self, mtu, status):
+ self.owner.dispatch("on_gatt_release")
+ self.owner.dispatch("on_mtu_changed", mtu, status)
+
+ return PythonBluetoothProxy(dispatcher)
+
+
+class BluetoothDispatcher:
+ """Subset of able.BluetoothDispatcher used by RNS RNode BLEConnection."""
+
+ def __init__(
+ self, queue_timeout: float = 0.5, enable_ble_code: int = 0xAB1E, **_kwargs
+ ):
+ java = _java()
+ self.queue_timeout = queue_timeout
+ self.enable_ble_code = enable_ble_code
+ self.queue = BLEQueue(timeout=queue_timeout)
+ self._events_interface = _make_python_bluetooth_proxy(self)
+ self._ble = java["BLE"](self._events_interface)
+
+ def dispatch(self, event_name, *args):
+ handler = getattr(self, event_name, None)
+ if callable(handler):
+ return handler(*args)
+ return None
+
+ @property
+ def gatt(self):
+ return self._ble.getGatt()
+
+ @property
+ def adapter(self):
+ return _java()["BluetoothAdapter"].getDefaultAdapter()
+
+ @property
+ def bonded_devices(self):
+ java = _java()
+ adapter = self.adapter
+ if adapter is None:
+ return []
+ ble_types = (
+ java["BluetoothDevice"].DEVICE_TYPE_LE,
+ java["BluetoothDevice"].DEVICE_TYPE_DUAL,
+ )
+ try:
+ devices = list(adapter.getBondedDevices().toArray())
+ except Exception:
+ devices = list(adapter.getBondedDevices())
+ return [dev for dev in devices if int(dev.getType()) in ble_types]
+
+ def connect_by_device_address(self, address: str, autoconnect: bool = False):
+ java = _java()
+ address = str(address).upper()
+ if not java["BluetoothAdapter"].checkBluetoothAddress(address):
+ raise ValueError(f"{address} is not a valid Bluetooth address")
+ adapter = self.adapter
+ if adapter is None:
+ raise OSError("Bluetooth adapter unavailable")
+ device = adapter.getRemoteDevice(address)
+ self.connect_gatt(device, autoconnect)
+
+ def connect_gatt(self, device, autoconnect: bool = False):
+ self._ble.connectGatt(device, autoconnect)
+
+ def close_gatt(self):
+ self._ble.closeGatt()
+
+ def discover_services(self):
+ gatt = self.gatt
+ if gatt is None:
+ return False
+ return gatt.discoverServices()
+
+ def enable_notifications(self, characteristic, enable=True, indication=False):
+ java = _java()
+ gatt = self.gatt
+ if gatt is None:
+ return False
+ if not gatt.setCharacteristicNotification(characteristic, enable):
+ return False
+ if not enable:
+ descriptor_value = java["DISABLE_NOTIFICATION_VALUE"]
+ elif indication:
+ descriptor_value = java["ENABLE_INDICATION_VALUE"]
+ else:
+ descriptor_value = java["ENABLE_NOTIFICATION_VALUE"]
+ try:
+ descriptors = list(characteristic.getDescriptors().toArray())
+ except Exception:
+ descriptors = list(characteristic.getDescriptors())
+ for descriptor in descriptors:
+ self.write_descriptor(descriptor, descriptor_value)
+ return True
+
+ @ble_task
+ def write_descriptor(self, descriptor, value):
+ payload = _to_java_bytes(value)
+ if not descriptor.setValue(payload):
+ return
+ gatt = self.gatt
+ if gatt is None:
+ return
+ gatt.writeDescriptor(descriptor)
+
+ @ble_task
+ def write_characteristic(self, characteristic, value, write_type=None):
+ payload = _to_java_bytes(value)
+ write_type_int = int(write_type or 0)
+ self._ble.writeCharacteristic(characteristic, payload, write_type_int)
+
+ @ble_task
+ def request_mtu(self, mtu: int):
+ gatt = self.gatt
+ if gatt is None:
+ return
+ gatt.requestMtu(int(mtu))
+
+ @ble_task_done
+ def on_gatt_release(self):
+ pass
+
+ def on_error(self, msg):
+ raise OSError(str(msg))
+
+ def on_scan_started(self, success):
+ pass
+
+ def on_scan_completed(self):
+ pass
+
+ def on_device(self, device, rssi, advertisement):
+ pass
+
+ def on_connection_state_change(self, status, state):
+ pass
+
+ def on_bluetooth_adapter_state_change(self, state):
+ pass
+
+ def on_services(self, status, services):
+ pass
+
+ def on_characteristic_changed(self, characteristic):
+ pass
+
+ def on_characteristic_read(self, characteristic, status):
+ pass
+
+ def on_characteristic_write(self, characteristic, status):
+ pass
+
+ def on_descriptor_read(self, descriptor, status):
+ pass
+
+ def on_descriptor_write(self, descriptor, status):
+ pass
+
+ def on_rssi_updated(self, rssi, status):
+ pass
+
+ def on_mtu_changed(self, mtu, status):
+ pass

diff --git a/android/app/src/main/python/able/queue.py b/android/app/src/main/python/able/queue.py
new file mode 100644
index 00000000..58251775
--- /dev/null
+++ b/android/app/src/main/python/able/queue.py
@@ -0,0 +1,77 @@
+# SPDX-License-Identifier: MIT
+"""BLE operation queue without Kivy Clock."""
+
+from __future__ import annotations
+
+import threading
+from functools import partial, wraps
+from queue import Empty, Queue
+
+
+def ble_task(method):
+ @wraps(method)
+ def wrapper(obj, *args, **kwargs):
+ task = partial(method, obj, *args, **kwargs)
+ obj.queue.enque(task)
+
+ return wrapper
+
+
+def ble_task_done(method):
+ @wraps(method)
+ def wrapper(obj, *args, **kwargs):
+ obj.queue.done()
+ return method(obj, *args, **kwargs)
+
+ return wrapper
+
+
+class BLEQueue:
+ def __init__(self, timeout=0.0):
+ self.lock = threading.Lock()
+ self.ready = True
+ self.queue = Queue()
+ self.timeout = float(timeout or 0.0)
+ self._timer = None
+
+ def set_timeout(self, timeout):
+ self.timeout = float(timeout or 0.0)
+
+ def enque(self, task):
+ if self.timeout == 0:
+ self.execute_task(task)
+ return
+ self.queue.put_nowait(task)
+ self.execute_next()
+
+ def execute_next(self, ready=False):
+ with self.lock:
+ if ready:
+ self.ready = True
+ elif not self.ready:
+ return
+ try:
+ task = self.queue.get_nowait()
+ except Empty:
+ return
+ self.ready = False
+ if task is not None:
+ self.execute_task(task)
+
+ def done(self, *args, **kwargs):
+ timer = self._timer
+ self._timer = None
+ if timer is not None:
+ try:
+ timer.cancel()
+ except Exception:
+ pass
+ self.execute_next(ready=True)
+
+ def execute_task(self, task):
+ if self.timeout > 0:
+ timer = threading.Timer(self.timeout, self.done)
+ timer.daemon = True
+ self._timer = timer
+ timer.start()
+ task()

diff --git a/android/app/src/main/python/able/structures.py b/android/app/src/main/python/able/structures.py
new file mode 100644
index 00000000..419c3b59
--- /dev/null
+++ b/android/app/src/main/python/able/structures.py
@@ -0,0 +1,49 @@
+# SPDX-License-Identifier: MIT
+"""Advertisement and GATT service helpers (from able)."""
+
+from __future__ import annotations
+
+import re
+from collections import namedtuple
+
+
+class Advertisement:
+ AD = namedtuple("AD", ["ad_type", "data"])
+
+ class ad_types:
+ flags = 0x01
+ complete_local_name = 0x09
+ service_data = 0x16
+ manufacturer_specific_data = 0xFF
+
+ def __init__(self, data):
+ self.data = data
+
+ def __iter__(self):
+ return Advertisement.parse(self.data)
+
+ @classmethod
+ def parse(cls, data):
+ pos = 0
+ while pos < len(data):
+ length = data[pos]
+ if length < 2:
+ return
+ try:
+ ad_type = data[pos + 1]
+ except IndexError:
+ return
+ next_pos = pos + length + 1
+ if ad_type:
+ segment = slice(pos + 2, next_pos)
+ yield Advertisement.AD(ad_type, bytearray(data[segment]))
+ pos = next_pos
+
+
+class Services(dict):
+ def search(self, pattern, flags=re.IGNORECASE):
+ for characteristics in self.values():
+ for uuid, characteristic in characteristics.items():
+ if re.search(pattern, uuid, flags):
+ return characteristic
+ return None

diff --git a/android/app/src/main/python/jnius/__init__.py b/android/app/src/main/python/jnius/__init__.py
new file mode 100644
index 00000000..76ce57f5
--- /dev/null
+++ b/android/app/src/main/python/jnius/__init__.py
@@ -0,0 +1,73 @@
+# SPDX-License-Identifier: 0BSD
+"""PyJNIus-compatible facade over Chaquopy's ``java`` module.
+
+RNS, usb4a, and related Android serial/Bluetooth code import ``jnius``.
+Chaquopy does not ship pyjnius. Map the small surface those libraries need
+onto Chaquopy's native Java bridge so RNode USB and classic Bluetooth work.
+"""
+
+from __future__ import annotations
+
+JavaException = Exception
+
+try:
+ from java import cast as _java_cast
+ from java import dynamic_proxy
+ from java import jclass
+except ImportError as exc: # pragma: no cover - desktop import path
+ raise ImportError("jnius Chaquopy shim requires the Chaquopy java module") from exc
+
+
+def autoclass(class_name: str):
+ """Return a Java class, matching pyjnius ``autoclass``."""
+ return jclass(class_name)
+
+
+def cast(cls, obj):
+ """Cast ``obj`` to ``cls``, accepting a class name string like pyjnius."""
+ if isinstance(cls, str):
+ cls = jclass(cls)
+ return _java_cast(cls, obj)
+
+
+def java_method(_signature):
+ """No-op decorator. Chaquopy dynamic proxies do not need JNI signatures."""
+
+ def decorator(fn):
+ return fn
+
+ return decorator
+
+
+class PythonJavaClass:
+ """Base that rebinds subclasses onto ``java.dynamic_proxy`` interfaces.
+
+ Subclasses set ``__javainterfaces__`` to a list of Java interface names
+ (dot or slash form). Instantiation switches the instance class bases so
+ method implementations are visible to Java callers.
+ """
+
+ __javainterfaces__: list[str] = []
+ __javacontext__ = "app"
+
+ def __init__(self, *args, **kwargs):
+ interfaces = list(getattr(self, "__javainterfaces__", []) or [])
+ if not interfaces:
+ return
+ resolved = []
+ for name in interfaces:
+ jni_name = name.replace("/", ".")
+ resolved.append(jclass(jni_name))
+ proxy_base = dynamic_proxy(*resolved)
+ self.__class__.__bases__ = (proxy_base, object)
+
+
+__all__ = [
+ "JavaException",
+ "PythonJavaClass",
+ "autoclass",
+ "cast",
+ "dynamic_proxy",
+ "java_method",
+ "jclass",
+]

diff --git a/android/app/src/main/python/meshchat_wrapper.py b/android/app/src/main/python/meshchat_wrapper.py
index 901cc9b8..4e48dc4e 100644
--- a/android/app/src/main/python/meshchat_wrapper.py
+++ b/android/app/src/main/python/meshchat_wrapper.py
@@ -121,7 +121,20 @@ def _patch_rns_panic_for_android():
return False
-def start_server(port=8000, app_files_dir=None):
+def _install_android_rnode_support(activity=None):
+ try:
+ from meshchatx.src.backend.android_rnode import install_android_rnode_support
+
+ ok = install_android_rnode_support(activity)
+ if ok:
+ print("meshchat_wrapper: Android RNode USB/Bluetooth support ready")
+ else:
+ print("meshchat_wrapper: Android RNode support not fully configured")
+ except Exception as exc:
+ print(f"meshchat_wrapper: Android RNode support skipped: {exc}")
+
+
+def start_server(port=8000, app_files_dir=None, activity=None):
global _server_loop_active
with _server_loop_lock:
if _server_loop_active:
@@ -154,6 +167,7 @@ def start_server(port=8000, app_files_dir=None):
asyncio_signal_patch = _patch_asyncio_signal_handlers_for_android()
aiohttp_run_app_patch = _patch_aiohttp_run_app_for_android()
_patch_rns_panic_for_android()
+ _install_android_rnode_support(activity)
try:
from meshchatx.android_codec2 import (
ensure_codec2_native_library,

diff --git a/android/app/src/main/python/usb4a/__init__.py b/android/app/src/main/python/usb4a/__init__.py
new file mode 100644
index 00000000..e5aa6d21
--- /dev/null
+++ b/android/app/src/main/python/usb4a/__init__.py
@@ -0,0 +1,8 @@
+# SPDX-License-Identifier: MIT
+"""USB helpers for Android (Chaquopy build of usb4a).
+
+Upstream usb4a expects Kivy ``PythonActivity`` via pyjnius. MeshChatX injects
+the Activity context at startup and uses the Chaquopy jnius shim instead.
+"""
+
+__version__ = "0.3.0-meshchatx"

diff --git a/android/app/src/main/python/usb4a/usb.py b/android/app/src/main/python/usb4a/usb.py
new file mode 100644
index 00000000..e0b1aef8
--- /dev/null
+++ b/android/app/src/main/python/usb4a/usb.py
@@ -0,0 +1,111 @@
+# SPDX-License-Identifier: MIT
+"""USB module for Android (Chaquopy / MeshChatX).
+
+Based on usb4a 0.3.0 by Quan Lin. Context comes from MeshChatX instead of
+Kivy ``org.kivy.android.PythonActivity``.
+"""
+
+from __future__ import annotations
+
+from jnius import autoclass
+
+Context = autoclass("android.content.Context")
+Intent = autoclass("android.content.Intent")
+PendingIntent = autoclass("android.app.PendingIntent")
+UsbConstants = autoclass("android.hardware.usb.UsbConstants")
+UsbRequest = autoclass("android.hardware.usb.UsbRequest")
+ByteBuffer = autoclass("java.nio.ByteBuffer")
+
+USB_RECIPIENT_DEVICE = 0x00
+USB_RECIPIENT_INTERFACE = 0x01
+USB_RECIPIENT_ENDPOINT = 0x02
+USB_RECIPIENT_OTHER = 0x03
+
+# Set by meshchat_wrapper from the Android Activity before RNS starts.
+context = None
+
+
+class USBError(IOError):
+ """USB Error class."""
+
+
+def set_context(android_context) -> None:
+ """Install the Android Context used for UsbManager lookups."""
+ global context
+ context = android_context
+
+
+def get_context():
+ """Return the injected Activity / Context."""
+ if context is None:
+ raise RuntimeError(
+ "USB context is not set. MeshChatX must pass the Activity into "
+ "meshchat_wrapper.start_server before opening RNode USB ports."
+ )
+ return context
+
+
+def get_usb_manager():
+ """Get USB manager object from the system."""
+ return get_context().getSystemService("usb")
+
+
+def _device_list_values(usb_manager):
+ device_map = usb_manager.getDeviceList()
+ values = device_map.values()
+ try:
+ return list(values.toArray())
+ except Exception:
+ try:
+ return list(values)
+ except Exception:
+ result = []
+ iterator = values.iterator()
+ while iterator.hasNext():
+ result.append(iterator.next())
+ return result
+
+
+def get_usb_device_list():
+ """Get USB device list."""
+ return _device_list_values(get_usb_manager())
+
+
+def get_usb_device(device_name):
+ """Get a USB device object by device name path."""
+ for usb_device in get_usb_device_list():
+ if usb_device and str(usb_device.getDeviceName()) == str(device_name):
+ return usb_device
+ return None
+
+
+def has_usb_permission(usb_device):
+ """True when permission is granted for the given USB device."""
+ return bool(get_usb_manager().hasPermission(usb_device))
+
+
+def request_usb_permission(usb_device):
+ """Request permission for the given USB device."""
+ usb_manager = get_usb_manager()
+ action = "com.meshchatx.USB_PERMISSION"
+ intent = Intent(action)
+ try:
+ pintent = PendingIntent.getBroadcast(get_context(), 0, intent, 0)
+ except Exception:
+ pintent = PendingIntent.getBroadcast(
+ get_context(),
+ 0,
+ intent,
+ PendingIntent.FLAG_IMMUTABLE,
+ )
+ usb_manager.requestPermission(usb_device, pintent)
+
+
+def build_usb_control_request_type(direction, usb_type, recipient):
+ """Build USB control request type for USB communication."""
+ return direction | usb_type | recipient
+
+
+def arraycopy(source, sourcepos, dest, destpos, numelem):
+ """Python version of System.arraycopy() in Java."""
+ dest[destpos : destpos + numelem] = source[sourcepos : sourcepos + numelem]

diff --git a/android/app/src/main/res/xml/device_filter.xml b/android/app/src/main/res/xml/device_filter.xml
new file mode 100644
index 00000000..ea67a38f
--- /dev/null
+++ b/android/app/src/main/res/xml/device_filter.xml
@@ -0,0 +1,45 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<resources>
+ <!-- 0x0403 / 0x60??: FTDI -->
+ <usb-device vendor-id="1027" product-id="24577" /> <!-- 0x6001: FT232R -->
+ <usb-device vendor-id="1027" product-id="24592" /> <!-- 0x6010: FT2232H -->
+ <usb-device vendor-id="1027" product-id="24593" /> <!-- 0x6011: FT4232H -->
+ <usb-device vendor-id="1027" product-id="24596" /> <!-- 0x6014: FT232H -->
+ <usb-device vendor-id="1027" product-id="24597" /> <!-- 0x6015: FT230X, FT231X, FT234XD -->
+
+ <!-- 0x10C4 / 0xEA??: Silabs CP210x -->
+ <usb-device vendor-id="4292" product-id="60000" /> <!-- 0xea60: CP2102 and other CP210x single port devices -->
+ <usb-device vendor-id="4292" product-id="60016" /> <!-- 0xea70: CP2105 -->
+ <usb-device vendor-id="4292" product-id="60017" /> <!-- 0xea71: CP2108 -->
+
+ <!-- 0x067B / 0x23?3: Prolific PL2303x -->
+ <usb-device vendor-id="1659" product-id="8963" /> <!-- 0x2303: PL2303HX, HXD, TA, ... -->
+ <usb-device vendor-id="1659" product-id="9123" /> <!-- 0x23a3: PL2303GC -->
+ <usb-device vendor-id="1659" product-id="9139" /> <!-- 0x23b3: PL2303GB -->
+ <usb-device vendor-id="1659" product-id="9155" /> <!-- 0x23c3: PL2303GT -->
+ <usb-device vendor-id="1659" product-id="9171" /> <!-- 0x23d3: PL2303GL -->
+ <usb-device vendor-id="1659" product-id="9187" /> <!-- 0x23e3: PL2303GE -->
+ <usb-device vendor-id="1659" product-id="9203" /> <!-- 0x23f3: PL2303GS -->
+
+ <!-- 0x1a86 / 0x?523: Qinheng CH34x -->
+ <usb-device vendor-id="6790" product-id="21795" /> <!-- 0x5523: CH341A -->
+ <usb-device vendor-id="6790" product-id="29987" /> <!-- 0x7523: CH340 -->
+
+ <!-- CDC driver -->
+ <usb-device vendor-id="9025" /> <!-- 0x2341 / ......: Arduino -->
+ <usb-device vendor-id="5824" product-id="1155" /> <!-- 0x16C0 / 0x0483: Teensyduino -->
+ <usb-device vendor-id="1003" product-id="8260" /> <!-- 0x03EB / 0x2044: Atmel Lufa -->
+ <usb-device vendor-id="7855" product-id="4" /> <!-- 0x1eaf / 0x0004: Leaflabs Maple -->
+ <usb-device vendor-id="3368" product-id="516" /> <!-- 0x0d28 / 0x0204: ARM mbed -->
+ <usb-device vendor-id="1155" product-id="22336" /><!-- 0x0483 / 0x5740: ST CDC -->
+ <usb-device vendor-id="11914" product-id="5" /> <!-- 0x2E8A / 0x0005: Raspberry Pi Pico Micropython -->
+ <usb-device vendor-id="11914" product-id="10" /> <!-- 0x2E8A / 0x000A: Raspberry Pi Pico SDK -->
+ <usb-device vendor-id="6790" product-id="21972" /><!-- 0x1A86 / 0x55D4: Qinheng CH9102F -->
+
+ <!-- Adafruit RAK4630 -->
+ <usb-device vendor-id="9114" product-id="32809" /><!-- 0x239a / 0x8029: RAK4630 -->
+
+ <!-- LilyGO T3S3 v1.2 -->
+ <usb-device vendor-id="12346" product-id="4097" /><!-- 0x303A / 0x1001: LilyGO T3S3 v1.2 -->
+</resources>
\ No newline at end of file

diff --git a/docs/agents/skills/android-webview-bridge/SKILL.md b/docs/agents/skills/android-webview-bridge/SKILL.md
index 8a47f18d..3f0c0a8a 100644
--- a/docs/agents/skills/android-webview-bridge/SKILL.md
+++ b/docs/agents/skills/android-webview-bridge/SKILL.md
@@ -27,10 +27,22 @@ Keep Chaquopy backend boot, WebView file choosers, storage locks, and external n
- Vendored `lxmfy` is synced into Chaquopy `src/main/python/`. Android pip does not install it like desktop setuptools.
- RNS panic containment matters on Android (see `deferred-network-startup`).
+## RNode on Android
+
+- Chaquopy has no pyjnius. Ship `android/app/src/main/python/jnius/` as a shim over `java.jclass`.
+- Override `usb4a` under `android/app/src/main/python/usb4a/` and inject the Activity via `meshchat_wrapper.start_server(..., activity)`.
+- BLE uses bundled `able` plus `org.able.BLE` (not Kivy PythonActivity).
+- Keep RNS panic containment and `panic_on_interface_error = No`.
+
## Key files
- `android/app/src/main/java/com/meshchatx/MainActivity.java`
+- `android/app/src/main/java/org/able/BLE.java`
- `android/app/src/main/python/meshchat_wrapper.py`
+- `android/app/src/main/python/jnius/`
+- `android/app/src/main/python/usb4a/`
+- `android/app/src/main/python/able/`
+- `meshchatx/src/backend/android_rnode/`
- `meshchatx/src/frontend/js/rnode/AndroidBridge.js`
- `docs/agents/conventions/android.md`

diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index c3d653b6..85f79448 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -6233,8 +6233,9 @@ class ReticulumMeshChat:
if _is_chaquopy_android():
message = (
"This RNode connection type is not available on this device. "
- "On Android, USB serial and Bluetooth need usbserial4a and jnius "
- "(see MeshChatX issue #6); RNode over IP (TCP) is unaffected."
+ "On Android, USB serial and classic Bluetooth need the bundled "
+ "USB host stack, and BLE needs the bundled able stack. "
+ "RNode over IP (TCP) is unaffected."
)
else:
message = (

diff --git a/meshchatx/src/backend/android_rnode/__init__.py b/meshchatx/src/backend/android_rnode/__init__.py
new file mode 100644
index 00000000..5aa63a55
--- /dev/null
+++ b/meshchatx/src/backend/android_rnode/__init__.py
@@ -0,0 +1,56 @@
+# SPDX-License-Identifier: 0BSD
+"""Install Chaquopy RNode USB / Bluetooth support before RNS starts."""
+
+from __future__ import annotations
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+def install_android_rnode_support(activity=None) -> bool:
+ """Wire Activity context into usb4a and org.able.BLE.
+
+ Returns True when the Android RNode support path was configured.
+ Safe to call on desktop (no-op when Chaquopy java APIs are missing).
+ """
+ if activity is None:
+ logger.warning("install_android_rnode_support called without Activity")
+ return False
+
+ configured = False
+
+ try:
+ from java import jclass
+
+ ble_cls = jclass("org.able.BLE")
+ ble_cls.setAppContext(activity)
+ configured = True
+ logger.info("Configured org.able.BLE app context for RNode BLE")
+ except Exception as exc:
+ logger.warning("Could not configure org.able.BLE context: %s", exc)
+
+ try:
+ from usb4a import usb as usb4a_usb
+
+ usb4a_usb.set_context(activity)
+ configured = True
+ logger.info("Configured usb4a context for RNode USB serial")
+ except Exception as exc:
+ logger.warning("Could not configure usb4a context: %s", exc)
+
+ try:
+ import jnius # noqa: F401
+
+ logger.info("jnius Chaquopy shim importable for RNode serial/Bluetooth")
+ except Exception as exc:
+ logger.warning("jnius shim not importable: %s", exc)
+
+ try:
+ import able # noqa: F401
+
+ logger.info("able BLE package importable for RNode ble:// ports")
+ except Exception as exc:
+ logger.warning("able package not importable: %s", exc)
+
+ return configured

diff --git a/meshchatx/src/backend/rnode_support.py b/meshchatx/src/backend/rnode_support.py
index 33fdffef..398f845f 100644
--- a/meshchatx/src/backend/rnode_support.py
+++ b/meshchatx/src/backend/rnode_support.py
@@ -3,12 +3,13 @@
"""RNode USB serial / Bluetooth / BLE support checks for desktop and Android.
RNode over TCP ("RNode over IP") needs no native Android modules and works
-unconditionally, since RNS's Android RNodeInterface is patched (see
-scripts/build-android-wheels-local.sh) to stop hard-crashing the process when
-usbserial4a/jnius are missing. Serial and classic-Bluetooth ports need
-usbserial4a + jnius. BLE (ble://) ports need able. This module lets the rest
-of the app tell which RNode config entries can actually be brought up on the
-current build, so only the genuinely unsupported ones get disabled.
+unconditionally. On Android, MeshChatX ships a Chaquopy jnius shim plus a
+patched usb4a context bridge so USB serial and classic Bluetooth can use
+usbserial4a. BLE (ble://) uses the bundled able package and org.able.BLE.
+Serial and classic-Bluetooth still need usbserial4a + jnius. BLE needs able.
+This module lets the rest of the app tell which RNode config entries can
+actually be brought up on the current build, so only the genuinely
+unsupported ones get disabled.
"""
from __future__ import annotations
@@ -44,18 +45,20 @@ def android_usbserial4a_available() -> bool:
def android_jnius_available() -> bool:
- """True when jnius (pyjnius) can be imported.
+ """True when jnius (or the Chaquopy jnius shim) can be imported.
RNS's Android-specific RNodeInterface needs jnius for USB serial and
- classic Bluetooth (RFCOMM) access. Chaquopy does not ship pyjnius under
- the importable name "jnius" unless bundled explicitly (e.g. via a
- compatibility shim), so this is normally unavailable.
+ classic Bluetooth (RFCOMM) access. MeshChatX ships a Chaquopy-backed
+ shim under android/app/src/main/python/jnius so this resolves on device.
"""
return _optional_module_available("jnius")
def android_able_available() -> bool:
- """True when able can be imported (BLE GATT support for RNode ble:// on Android)."""
+ """True when able can be imported (BLE GATT support for RNode ble://).
+
+ MeshChatX ships a Chaquopy-compatible able package plus org.able.BLE.
+ """
return _optional_module_available("able")

diff --git a/tests/backend/test_able_android.py b/tests/backend/test_able_android.py
new file mode 100644
index 00000000..75231781
--- /dev/null
+++ b/tests/backend/test_able_android.py
@@ -0,0 +1,44 @@
+# SPDX-License-Identifier: 0BSD
+
+import sys
+from pathlib import Path
+
+import pytest
+
+ABLE_ROOT = (
+ Path(__file__).resolve().parents[2] / "android" / "app" / "src" / "main" / "python"
+)
+
+
+@pytest.fixture
+def able_path(monkeypatch):
+ monkeypatch.syspath_prepend(str(ABLE_ROOT))
+ for name in list(sys.modules):
+ if name == "able" or name.startswith("able."):
+ monkeypatch.delitem(sys.modules, name, raising=False)
+ yield
+ for name in list(sys.modules):
+ if name == "able" or name.startswith("able."):
+ monkeypatch.delitem(sys.modules, name, raising=False)
+
+
+def test_able_services_search(able_path):
+ from able.structures import Services
+
+ services = Services(
+ {
+ "service0": {"c1-aa": 0, "aa-c2-aa": 1},
+ "service1": {"bb-c3-bb": 2},
+ }
+ )
+ assert services.search("c3") == 2
+ assert services.search("c4") is None
+
+
+def test_able_queue_runs_immediate_tasks(able_path):
+ from able.queue import BLEQueue
+
+ seen = []
+ queue = BLEQueue(timeout=0)
+ queue.enque(lambda: seen.append("ok"))
+ assert seen == ["ok"]

diff --git a/tests/backend/test_android_rnode_support.py b/tests/backend/test_android_rnode_support.py
new file mode 100644
index 00000000..2d8f9f85
--- /dev/null
+++ b/tests/backend/test_android_rnode_support.py
@@ -0,0 +1,62 @@
+# SPDX-License-Identifier: 0BSD
+
+from meshchatx.src.backend.android_rnode import install_android_rnode_support
+
+
+def test_install_android_rnode_support_without_activity_returns_false():
+ assert install_android_rnode_support(None) is False
+
+
+def test_install_android_rnode_support_sets_usb4a_context(monkeypatch):
+ calls = {}
+
+ class FakeUsb:
+ @staticmethod
+ def set_context(activity):
+ calls["usb"] = activity
+
+ class FakeBle:
+ @staticmethod
+ def setAppContext(activity):
+ calls["ble"] = activity
+
+ class FakeJclass:
+ def __call__(self, name):
+ assert name == "org.able.BLE"
+ return FakeBle
+
+ import sys
+ import types
+
+ usb4a_mod = types.ModuleType("usb4a")
+ usb4a_usb = types.ModuleType("usb4a.usb")
+ usb4a_usb.set_context = FakeUsb.set_context
+ usb4a_mod.usb = usb4a_usb
+
+ java_mod = types.ModuleType("java")
+ java_mod.jclass = FakeJclass()
+
+ jnius_mod = types.ModuleType("jnius")
+ able_mod = types.ModuleType("able")
+
+ monkeypatch.setitem(sys.modules, "usb4a", usb4a_mod)
+ monkeypatch.setitem(sys.modules, "usb4a.usb", usb4a_usb)
+ monkeypatch.setitem(sys.modules, "java", java_mod)
+ monkeypatch.setitem(sys.modules, "jnius", jnius_mod)
+ monkeypatch.setitem(sys.modules, "able", able_mod)
+
+ activity = object()
+ assert install_android_rnode_support(activity) is True
+ assert calls["usb"] is activity
+ assert calls["ble"] is activity
+
+
+def test_rnode_api_message_has_no_github_issue_reference():
+ message = (
+ "This RNode connection type is not available on this device. "
+ "On Android, USB serial and classic Bluetooth need the bundled "
+ "USB host stack, and BLE needs the bundled able stack. "
+ "RNode over IP (TCP) is unaffected."
+ )
+ assert "issue #" not in message.lower()
+ assert "github" not in message.lower()

diff --git a/tests/backend/test_interface_options.py b/tests/backend/test_interface_options.py
index 3bf94a3b..24ecf309 100644
--- a/tests/backend/test_interface_options.py
+++ b/tests/backend/test_interface_options.py
@@ -537,6 +537,8 @@ async def test_rnode_serial_blocked_on_android_without_usbserial4a_or_jnius(temp
body = json.loads(response.body)
assert response.status == 422, body
assert "RNode over IP" in body["message"]
+ assert "issue #" not in body["message"].lower()
+ assert "github" not in body["message"].lower()
assert "Radio" not in config["interfaces"]


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────